The turret logic is driven by a Behavior Tree in combination with a Blackboard, which maintains and synchronizes the turret’s state according to predefined conditions.
An AIController processes sensory input through its perception system and coordinates the target selection workflow.
/*
* Constructor
*/
ASDTurretController::ASDTurretController()
{
AIPerceptionComponent = CreateDefaultSubobject<UAIPerceptionComponent>(TEXT("AIPerceptionComponent"));
UAISenseConfig_Sight* SightConfig = CreateDefaultSubobject<UAISenseConfig_Sight>(TEXT("SightConfig"));
SightConfig->SightRadius = 1500.0f;
SightConfig->LoseSightRadius = 2000.0f;
SightConfig->PeripheralVisionAngleDegrees = 90.0f;
SightConfig->SetMaxAge(2.0f);
SightConfig->DetectionByAffiliation.bDetectEnemies = true;
SightConfig->DetectionByAffiliation.bDetectNeutrals = true;
SightConfig->DetectionByAffiliation.bDetectFriendlies = true;
//NOTE: TimeUntilNextUpdate on AISense regulates the Tick intervalls for the sight perception
AIPerceptionComponent->ConfigureSense(*SightConfig);
AIPerceptionComponent->SetDominantSense(SightConfig->GetSenseImplementation());
}
/*
* Initializes and runs the assigned Behavior Tree while binding custom functions to perception events
*/
void ASDTurretController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
PossessedTurret = CastChecked<ASDTurret>(InPawn);
#if IF_WITH_EDITOR
bDrawDebug = PossessedTurret->GetDrawDebug();
#endif
// Automatically initializes the Behavior Tree and sets up the Blackboard, which can then be accessed via Blackboard.Get().
RunBehaviorTree(BehvaviorTree);
if (IsValid(AIPerceptionComponent))
{
AIPerceptionComponent->OnTargetPerceptionUpdated.AddDynamic(this, &ASDTurretController::OnPerceptionUpdated);
AIPerceptionComponent->OnTargetPerceptionForgotten.AddDynamic(this, &ASDTurretController::OnPerceptionForgotten);
}
}
/*
* Triggered when the perception component receives a new stimulus
* Registers the detected actor and adds it to the list of known targets
*/
void ASDTurretController::OnPerceptionUpdated(AActor* TargetActor, FAIStimulus Stimulus)
{
TSubclassOf<UAISense> SenseClassByStimulus = UAIPerceptionSystem::GetSenseClassForStimulus(GetWorld(), Stimulus);
if (SenseClassByStimulus == UAISense_Sight::StaticClass())
{
if (Stimulus.WasSuccessfullySensed() && IsValid(TargetActor))
AllTargetActors.AddUnique(TargetActor);
}
}
/*
* Triggered when the perception component loses sight of a known actor
* Removes the actor from the target pool and clears the Blackboard entry if it was set as the active target
*/
void ASDTurretController::OnPerceptionForgotten(AActor* TargetActor)
{
RemoveTargetActor(TargetActor);
#if IF_WITH_EDITOR
if(bDrawDebug)
GEngine->AddOnScreenDebugMessage(-1, 2.0f, FColor::Purple, FString::Printf(TEXT("Forgot actor: %s"), *GetNameSafe(TargetActor)));
#endif
}
When a target enters perception range, a dedicated function is invoked through the AIController´s interface within the Behavior Tree task BTTask_TurretSelectTarget. This function assigns the current target in the Blackboard, enabling subsequent leaf nodes in the Behavior Tree to progress.
bool ASDTurretController::Controller_TurretSelectTarget()
{
if (IsValid(LastTargetActor) && PossessedTurret->GetCurrentTarget() == LastTargetActor)
{
SetTargetActor(LastTargetActor);
return true;
}
AActor* SelectedTarget = ChooseTargetActor();
if (IsValid(SelectedTarget))
{
SetTargetActor(SelectedTarget);
return true;
}
return false;
}
Once the current target is assigned in the Blackboard, the Behavior Tree transitions the turret into its combat state.
void ASDTurret::Character_TurretSetCombatState(bool bInCombat)
{
switch (CurrentTurretState)
{
case ETurretState::Idle:
if (bInCombat == true)
{
CurrentTurretState = ETurretState::Combat;
Blackboard->SetValueAsEnum(TurretStateBlackboardValueName, static_cast<uint8>(CurrentTurretState));
StartCombatState();
}
break;
case ETurretState::Combat:
if (bInCombat == false)
{
CurrentTurretState = ETurretState::Idle;
Blackboard->SetValueAsEnum(TurretStateBlackboardValueName, static_cast<uint8>(CurrentTurretState));
StartIdleState();
}
break;
}
}
This triggers a chain of events that activate the targeting system. A timer introduces controlled delays between each step in the firing sequence, providing precise pacing of the shooting process. When the combat state is entered, the IsAiming flag is set to true, and the actor begins its ticking behavior, which encapsulates the turret’s targeting logic.
void ASDTurret::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
if (IsValid(CurrentTarget))
{
bIsRotatingBackToStart = false;
if (CurrentTarget != LastProcessedTarget)
{
bHasStartedTargeting = false;
bLastTargetingStatus = false;
LastProcessedTarget = CurrentTarget;
#if WITH_EDITOR
if (bDrawDebug)
GEngine->AddOnScreenDebugMessage(-1, 2.f, FColor::Cyan, TEXT("New Target Detected – Resetting Targeting Status"));
#endif
}
Move the barrel towards the target’s position, start firing once aligned
InterpolateToPosition(DeltaSeconds, CurrentTarget->GetTargetLocation());
CheckIfTargetAligned();
}
else
{
// Handle loss of target
if (bWasTargetValidLastTick)
{
OnTargetLost();
}
// Return barrel to default position
if (bIsRotatingBackToStart)
{
InterpolateToPosition(DeltaSeconds, StartingLocation);
#if WITH_EDITOR
if (bDrawDebug)
{
//GEngine->AddOnScreenDebugMessage(-1, 2.f, FColor::Magenta, FString::Printf(TEXT("%s rotating back to start"), *GetNameSafe(this)));
/*GEngine->AddOnScreenDebugMessage(3, 2.f, FColor::Magenta,
FString::Printf(TEXT("CurrentTarget: %s | StartingLocation (Local): %s"),
*(GetMesh()->GetComponentTransform().TransformPosition(CurrentTargetLocation) - TargetOffsetVector).ToString(),
*StartingLocation.ToString()));*/
DrawDebugSphere(GetWorld(), StartingLocation, 20.f, 12, FColor::Green);
DrawDebugSphere(GetWorld(), GetMesh()->GetComponentTransform().TransformPosition(CurrentTargetLocation) - TargetOffsetVector, 20.f, 12, FColor::Red);
}
#endif
// Disable ticking once back at the default state
if ((GetMesh()->GetComponentTransform().TransformPosition(CurrentTargetLocation) - TargetOffsetVector).Equals(StartingLocation, 1.0f))
{
bIsRotatingBackToStart = false;
#if WITH_EDITOR
if (bDrawDebug)
GEngine->AddOnScreenDebugMessage(3, 2.f, FColor::Purple, FString::Printf(TEXT("%s arrived back at starting location"), *GetNameSafe(this)));
#endif
SetActorTickEnabled(false);
}
}
}
bWasTargetValidLastTick = IsValid(CurrentTarget);
}
#pragma region Tick Functionality
/*
* Rotates the barrel toward the CurrentTarget location at a defined rotation speed
*/
void ASDTurret::InterpolateToPosition(float DeltaSeconds, const FVector& InPosition)
{
TargetLocationWorldSpace = InPosition + TargetOffsetVector;
DesiredLocalPosition = GetMesh()->GetComponentTransform().InverseTransformPosition(TargetLocationWorldSpace);
if (SmoothedLocalTargetLocation.IsNearlyZero())
SmoothedLocalTargetLocation = GetAimTransform().InverseTransformPosition(GetCombatSocketLocation());
SmoothedLocalTargetLocation = FMath::VInterpConstantTo(
SmoothedLocalTargetLocation,
DesiredLocalPosition,
DeltaSeconds,
RotationSpeedDegrees
);
CurrentTargetLocation = SmoothedLocalTargetLocation;
#if WithEditor
if (bDrawDebug)
{
DrawDebugSphere(
GetWorld(),
GetMesh()->GetComponentTransform().TransformPosition(CurrentTargetLocation) - TargetOffsetVector,
25.f,
12,
FColor::Red,
false,
-1.f,
0,
1.0f
);
}
#endif
}
/*
* Verifies whether the barrel has aligned with the CurrentTarget
* If alignment is reached, the shooting process is triggered
*/
void ASDTurret::CheckIfTargetAligned()
{
const FVector MuzzleLocation = GetCombatSocketLocation();
const FVector MuzzleForward = GetAimTransform().GetRotation().GetForwardVector();
const FVector TargetLocation = CurrentTarget->GetActorLocation() + BarrelOffsetVector;
const FVector DirectionToTarget = (TargetLocation - MuzzleLocation).GetSafeNormal();
float Dot = FVector::DotProduct(MuzzleForward, DirectionToTarget);
Dot = FMath::Clamp(Dot, -1.0f, 1.0f);
const float AngleDeg = FMath::RadiansToDegrees(FMath::Acos(Dot));
#if WITH_EDITOR
if (bDrawDebug)
{
GEngine->AddOnScreenDebugMessage(0, 0.f, FColor::Yellow, FString::Printf(TEXT("Aim Error: %.1f°"), AngleDeg));
DrawDebugLine(GetWorld(), MuzzleLocation, MuzzleLocation + MuzzleForward * 200.f, FColor::Blue);
DrawDebugLine(GetWorld(), MuzzleLocation, MuzzleLocation + DirectionToTarget * 200.f, FColor::Red);
}
#endif
bIsAligned = AngleDeg <= AimErrorTolerance;
if (bIsAligned != bLastTargetingStatus)
{
bLastTargetingStatus = bIsAligned;
if(bIsAligned)
OnTargetAligned();
else
OnTargetLost();
}
}
void ASDTurret::OnTargetAligned()
{
if (bHasStartedTargeting)
return;
bHasStartedTargeting = true;
ApplyEffectToSelf(TurretShootingGameplayEffect, PlayerLevel);
StartTargeting();
#if WITH_EDITOR
if (bDrawDebug)
GEngine->AddOnScreenDebugMessage(-1, 2.f, FColor::Green, FString(TEXT("Aligned with Target")));
#endif
}
void ASDTurret::OnTargetLost()
{
bHasStartedTargeting = false;
RemoveGameplayEffectByTag(TurretShootingTags);
#if WITH_EDITOR
if(bDrawDebug)
GEngine->AddOnScreenDebugMessage(-1, 2.f, FColor::Red, FString(TEXT("Target was lost")));
#endif
}
#pragma endregion
Once the barrel aligns with the target, the OnTargetAligned function is invoked. It calls StartTargeting, which sets the IsTargeting flag to true and initiates the HandleShooting routine.
HandleShooting then activates the appropriate abilities defined for the turret. The abilities are resolved dynamically based on the GameplayTags currently assigned to the turret instance.
/// <summary>
/// The turret validates whether it possesses a specific GameplayTag
/// If present, the system attempts to activate the corresponding GameplayAbility
/// </summary>
void ASDTurret::HandleShooting()
{
if (!HasGameplayTag(TurretShootingTags))
{
#if WITH_EDITOR
if (bDrawDebug)
GEngine->AddOnScreenDebugMessage(-1, 2.f, FColor::Black, FString::Printf(TEXT("%s tried to Shoot without having the specified Tags"), *GetNameSafe(this)));
#endif
return;
}
if (bool bActive = AbilitySystemComponent->TryActivateAbilitiesByTag(TurretShootingTags))
{
#if WITH_EDITOR
if (bDrawDebug)
GEngine->AddOnScreenDebugMessage(-1, 0.f, FColor::Yellow, FString::Printf(TEXT("%s activated an Ability"), *GetNameSafe(this)));
#endif
}
}
/// <summary>
/// Once an ability finishes, the turret restarts the targeting process
/// This reset currently depends on the ability’s cooldown system, though this may evolve in future iterations
/// </summary>
void ASDTurret::OnAbilityEnded(const FAbilityEndedData& EndedData)
{
if (!EndedData.AbilityThatEnded)
return;
if (EndedData.AbilityThatEnded->GetAssetTags().HasAllExact(TurretShootingTags))
{
#if WITH_EDITOR
if(bDrawDebug)
GEngine->AddOnScreenDebugMessage(-1, 0.f, FColor::Yellow, FString::Printf(TEXT("%s Ability ended on %s"), *GetNameSafe(EndedData.AbilityThatEnded), *GetNameSafe(this)));
#endif
StartTargeting();
}
}
The turret’s core logic runs entirely on the server. Only essential data is replicated to clients — namely the current target information and a small set of key boolean variables. These replicated values are then interpreted by the client-side Animation Blueprint.
void ASDTurret::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ASDTurret, CurrentTargetLocation);
DOREPLIFETIME(ASDTurret, CurrentTargetDistance);
DOREPLIFETIME(ASDTurret, bIsAiming);
DOREPLIFETIME(ASDTurret, bIsTargetting);
DOREPLIFETIME(ASDTurret, bIsRotatingBackToStart);
}
Within the Animation Blueprint, the replicated values are consumed to drive turret animations and effects.
The Animation Blueprint reacts solely to the replicated boolean variables and forwards the current target location into the Control Rig’s barrel controller. Shooting itself is encapsulated within the ability:
Because this sequence is executed entirely within the Animation Blueprint, each client independently manages its animations and visual effects, ensuring proper synchronization without excessive replication overhead.
The Turret is capable of firing multiple types of data-driven projectiles, with projectile types fully interchangeable during gameplay.
Planned features include:
The project’s source code is linked on GitHub beneath the video at the top of the page.